Coverage Report

Created: 2026-06-19 16:17

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
D:\a\csshw\csshw\src\daemon\mod.rs
Line
Count
Source
1
//! Daemon implementation
2
3
#![deny(clippy::implicit_return)]
4
#![allow(clippy::needless_return, clippy::doc_overindented_list_items)]
5
#![warn(missing_docs)]
6
7
use std::collections::HashMap;
8
use std::{
9
    io,
10
    sync::{Arc, Mutex},
11
    time::Duration,
12
};
13
use std::{thread, time};
14
15
use crate::get_console_window_handle;
16
use crate::protocol::{
17
    deserialization::deserialize_pid,
18
    serialization::{serialize_client_state, serialize_highlight, serialize_input_record_0},
19
    ClientState, FRAMED_HIGHLIGHT_LENGTH, FRAMED_INPUT_RECORD_LENGTH, FRAMED_STATE_CHANGE_LENGTH,
20
    SERIALIZED_INPUT_RECORD_0_LENGTH, SERIALIZED_PID_LENGTH, TAG_HIGHLIGHT, TAG_INPUT_RECORD,
21
    TAG_KEEP_ALIVE, TAG_STATE_CHANGE,
22
};
23
use crate::utils::config::{Cluster, DaemonConfig, EdgeBehavior};
24
use crate::utils::debug::StringRepr;
25
use crate::utils::windows::{clear_screen, set_console_color, WindowsApi};
26
use crate::{
27
    current_exe_path, spawn_console_process,
28
    utils::{
29
        constants::{PIPE_NAME, PKG_NAME},
30
        windows::{
31
            arrange_console, get_console_input_buffer, read_keyboard_input,
32
            set_console_border_color,
33
        },
34
    },
35
    WindowsSettingsDefaultTerminalApplicationGuard,
36
};
37
use bracoxide::explode;
38
use log::{debug, error, warn};
39
use tokio::{
40
    net::windows::named_pipe::{NamedPipeServer, PipeMode, ServerOptions},
41
    sync::{
42
        broadcast::{self, error::RecvError, Receiver, Sender},
43
        watch,
44
    },
45
    task::JoinHandle,
46
};
47
use windows::Win32::System::Console::{
48
    CONSOLE_CHARACTER_ATTRIBUTES, INPUT_RECORD_0, KEY_EVENT_RECORD, LEFT_ALT_PRESSED,
49
    LEFT_CTRL_PRESSED, RIGHT_ALT_PRESSED, RIGHT_CTRL_PRESSED, SHIFT_PRESSED,
50
};
51
52
use windows::Win32::UI::Input::KeyboardAndMouse::{
53
    VIRTUAL_KEY, VK_A, VK_C, VK_D, VK_DOWN, VK_E, VK_ESCAPE, VK_H, VK_J, VK_K, VK_L, VK_LEFT, VK_N,
54
    VK_R, VK_RIGHT, VK_T, VK_UP,
55
};
56
use windows::Win32::UI::WindowsAndMessaging::{SW_RESTORE, SW_SHOWMINIMIZED, SW_SHOWNOACTIVATE};
57
use windows::Win32::{
58
    Foundation::{COLORREF, HANDLE, HWND, STILL_ACTIVE},
59
    System::{Console::ENABLE_PROCESSED_INPUT, Threading::PROCESS_QUERY_INFORMATION},
60
};
61
62
use self::grid::{grid_dimensions, ClientGrid};
63
use self::workspace::WorkspaceArea;
64
65
mod grid;
66
mod workspace;
67
68
/// The capacity of the broadcast channel used
69
/// to send the input records read from the console input buffer
70
/// to the named pipe servers connected to each client in parallel.
71
const SENDER_CAPACITY: usize = 1024 * 1024;
72
73
/// Bits in `KEY_EVENT_RECORD::dwControlKeyState` that represent
74
/// "real" modifier keys (Ctrl / Alt / Shift) as opposed to lock
75
/// toggles (`CAPSLOCK_ON`, `NUMLOCK_ON`, `SCROLLLOCK_ON`) or the
76
/// `ENHANCED_KEY` flag.
77
///
78
/// Control-mode key classification ANDs `dwControlKeyState` with
79
/// this mask before matching; otherwise an enabled CapsLock or
80
/// NumLock would make `dwControlKeyState` non-zero and silently
81
/// skip every `(VK_*, 0)` arm.
82
const MODIFIER_MASK: u32 =
83
    LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED | LEFT_ALT_PRESSED | RIGHT_ALT_PRESSED | SHIFT_PRESSED;
84
85
/// Top-level control-mode action a keystroke classifies into.
86
///
87
/// Extracted from [`Daemon::handle_input_record`]'s dispatch match
88
/// so the classification - including the [`MODIFIER_MASK`] step -
89
/// can be regression tested without instantiating a full
90
/// [`Daemon`].
91
#[derive(Debug, PartialEq, Eq)]
92
enum ControlModeAction {
93
    /// `[r]` - rearrange every client window.
94
    Retile,
95
    /// `[e]` - open the enable/disable input submenu.
96
    OpenEnableDisableSubmenu,
97
    /// `[t]` - flip each client's [`ClientState`].
98
    ToggleEnabled,
99
    /// `[n]` - force every client back to [`ClientState::Active`].
100
    EnableAll,
101
    /// `[c]` - prompt for new hostnames and launch additional clients.
102
    CreateWindows,
103
    /// `[h]` - copy the active clients' hostnames to the clipboard.
104
    CopyHostnames,
105
    /// Any other key in the active control-mode prompt.
106
    NoOp,
107
}
108
109
/// Enable/disable-submenu action a keystroke classifies into.
110
///
111
/// Extracted from [`Daemon::handle_enable_disable_submenu_key`]'s
112
/// dispatch match for the same reason as [`ControlModeAction`].
113
#[derive(Debug, PartialEq, Eq)]
114
enum EnableDisableSubmenuAction {
115
    /// `[e]` - force the targeted client(s) to [`ClientState::Active`].
116
    Enable,
117
    /// `[d]` - force the targeted client(s) to [`ClientState::Disabled`].
118
    Disable,
119
    /// `[t]` - flip the targeted client(s)' [`ClientState`].
120
    Toggle,
121
    /// Arrow key or vim motion - move the submenu's selection cursor.
122
    Navigate(NavigationDirection),
123
    /// Any other key while the submenu is open.
124
    NoOp,
125
}
126
127
/// Direction of a navigation keystroke inside the enable/disable
128
/// submenu.
129
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
130
enum NavigationDirection {
131
    Up,
132
    Down,
133
    Left,
134
    Right,
135
}
136
137
/// Classifies a top-level control-mode keystroke.
138
///
139
/// `control_key_state` is ANDed with [`MODIFIER_MASK`] so lock
140
/// toggles (`CAPSLOCK_ON`, `NUMLOCK_ON`, `SCROLLLOCK_ON`) and the
141
/// `ENHANCED_KEY` flag never bleed into the match - the
142
/// `(VK_*, 0)` arms must still fire while any of those bits are
143
/// set. Any "real" modifier bit (Ctrl / Alt / Shift) survives the
144
/// mask and falls through to [`ControlModeAction::NoOp`].
145
///
146
/// # Arguments
147
///
148
/// * `virtual_key`       - The pressed key's [`VIRTUAL_KEY`].
149
/// * `control_key_state` - The raw `dwControlKeyState` field from
150
///                         the [`KEY_EVENT_RECORD`].
151
///
152
/// # Returns
153
///
154
/// The [`ControlModeAction`] the dispatch should execute.
155
78
fn classify_control_mode_key(
156
78
    virtual_key: VIRTUAL_KEY,
157
78
    control_key_state: u32,
158
78
) -> ControlModeAction {
159
78
    return match (virtual_key, control_key_state & MODIFIER_MASK) {
160
6
        (VK_R, 0) => ControlModeAction::Retile,
161
6
        (VK_E, 0) => ControlModeAction::OpenEnableDisableSubmenu,
162
6
        (VK_T, 0) => ControlModeAction::ToggleEnabled,
163
6
        (VK_N, 0) => ControlModeAction::EnableAll,
164
6
        (VK_C, 0) => ControlModeAction::CreateWindows,
165
6
        (VK_H, 0) => ControlModeAction::CopyHostnames,
166
42
        _ => ControlModeAction::NoOp,
167
    };
168
78
}
169
170
/// Classifies an enable/disable-submenu keystroke.
171
///
172
/// See [`classify_control_mode_key`] for the [`MODIFIER_MASK`]
173
/// rationale; the same lock-state / `ENHANCED_KEY` masking applies
174
/// to the submenu so its `[e]`, `[d]`, `[t]` bindings keep working
175
/// regardless of lock state.
176
///
177
/// # Arguments
178
///
179
/// * `virtual_key`       - The pressed key's [`VIRTUAL_KEY`].
180
/// * `control_key_state` - The raw `dwControlKeyState` field from
181
///                         the [`KEY_EVENT_RECORD`].
182
///
183
/// # Returns
184
///
185
/// The [`EnableDisableSubmenuAction`] the dispatch should execute.
186
154
fn classify_enable_disable_submenu_key(
187
154
    virtual_key: VIRTUAL_KEY,
188
154
    control_key_state: u32,
189
154
) -> EnableDisableSubmenuAction {
190
154
    return match (virtual_key, control_key_state & MODIFIER_MASK) {
191
10
        (VK_E, 0) => EnableDisableSubmenuAction::Enable,
192
7
        (VK_D, 0) => EnableDisableSubmenuAction::Disable,
193
8
        (VK_T, 0) => EnableDisableSubmenuAction::Toggle,
194
13
        (VK_UP, 0) | (VK_K, 0) => EnableDisableSubmenuAction::Navigate(NavigationDirection::Up),
195
14
        (VK_DOWN, 0) | (VK_J, 0) => EnableDisableSubmenuAction::Navigate(NavigationDirection::Down),
196
12
        (VK_LEFT, 0) | (VK_H, 0) => EnableDisableSubmenuAction::Navigate(NavigationDirection::Left),
197
        (VK_RIGHT, 0) | (VK_L, 0) => {
198
12
            EnableDisableSubmenuAction::Navigate(NavigationDirection::Right)
199
        }
200
78
        _ => EnableDisableSubmenuAction::NoOp,
201
    };
202
154
}
203
204
/// Representation of a client
205
#[derive(Clone)]
206
struct Client {
207
    /// Hostname the client is connect to (or supposed to connect to).
208
    hostname: String,
209
    /// Window handle to the clients console window.
210
    window_handle: HWND,
211
    /// Process handle to the client process.
212
    process_handle: HANDLE,
213
    /// Process id of the client process.
214
    ///
215
    /// Used by the pipe server task to correlate which client has connected
216
    /// to it, via a handshake over the named pipe.
217
    process_id: u32,
218
    /// Authoritative source for this client's [`ClientState`].
219
    ///
220
    /// The daemon broadcasts new state values through the [`watch::Sender`];
221
    /// the assigned pipe-server task subscribes upon successful PID
222
    /// correlation and forwards every change to the client over the named
223
    /// pipe. [`watch::Sender`] is itself [`Clone`], so cloning a [`Client`]
224
    /// produces another sender that drives the same channel.
225
    state_sender: watch::Sender<ClientState>,
226
    /// Authoritative source for this client's highlight flag, set while
227
    /// the client is the daemon's currently selected submenu client.
228
    /// Visual only; input gating uses [`Client::state_sender`].
229
    highlight_sender: watch::Sender<bool>,
230
    /// Index passed to [`arrange_client_window`] when this client's
231
    /// on-screen position was last computed. Survives
232
    /// [`Clients::retain`] so the submenu navigation grid keeps
233
    /// matching the visible layout until the next retile.
234
    tile_index: usize,
235
}
236
237
unsafe impl Send for Client {}
238
239
/// Collection of [`Client`]s maintaining insertion order and a PID-indexed
240
/// lookup table.
241
///
242
/// The ordered list preserves client window placement semantics, while the
243
/// index enables O(1) lookup by process id - required by the pipe server task
244
/// during PID correlation and future per-client pipe server control.
245
struct Clients {
246
    /// Ordered list of clients; order matches launch order and is used for
247
    /// window arrangement and z-order synchronization.
248
    list: Vec<Client>,
249
    /// Maps a client's process id to its index in [`list`](Clients::list).
250
    pid_index: HashMap<u32, usize>,
251
    /// `number_of_consoles` value the current on-screen layout was
252
    /// computed with. Drives [`grid_dimensions`] for the submenu nav.
253
    /// Updated when the tiler positions windows
254
    /// ([`Clients::reset_tile_layout`]); preserved across
255
    /// [`Clients::retain`] so a closed-but-not-retiled window leaves
256
    /// a visible gap in the grid too.
257
    layout_n: usize,
258
}
259
260
impl Clients {
261
    /// Creates a new empty collection.
262
27
    fn new() -> Self {
263
27
        return Clients {
264
27
            list: Vec::new(),
265
27
            pid_index: HashMap::new(),
266
27
            layout_n: 0,
267
27
        };
268
27
    }
269
270
    /// Appends a client to the collection and records its position in the
271
    /// PID index.
272
    ///
273
    /// # Arguments
274
    ///
275
    /// * `client` - The [`Client`] to add.
276
    ///
277
    /// # Panics
278
    ///
279
    /// Panics if a client with the same process id is already present, as
280
    /// duplicate PIDs indicate broken daemon bookkeeping.
281
59
    fn push(&mut self, mut client: Client) {
282
59
        let index = self.list.len();
283
59
        assert!(
284
59
            !self.pid_index.contains_key(&client.process_id),
285
            "Duplicate client PID {} - daemon bookkeeping broken",
286
            client.process_id,
287
        );
288
        // Push assumes the new client occupies the next cell in a dense
289
        // layout - matching what the tiler does at initial launch and
290
        // right after `[c]reate`. `retain` leaves these values alone so
291
        // closed-but-not-retiled gaps stay visible to the navigation
292
        // grid. The next retile renumbers everything dense again.
293
58
        client.tile_index = index;
294
58
        self.pid_index.insert(client.process_id, index);
295
58
        self.list.push(client);
296
58
        self.layout_n = self.list.len();
297
58
    }
298
299
    /// Reassigns dense [`Client::tile_index`] values to `valid_pids` in
300
    /// the supplied order and snapshots the new [`Clients::layout_n`].
301
    ///
302
    /// Called by [`Daemon::rearrange_client_windows`] right before it
303
    /// re-positions the actual windows on screen, so navigation reads
304
    /// the same layout the tiler just applied.
305
    ///
306
    /// Invariant: `valid_pids` must cover every PID currently tracked
307
    /// in `self.list`. Passing a strict subset (e.g. a liveness-filtered
308
    /// list) would shrink `layout_n` while leaving stale `tile_index >=
309
    /// layout_n` values on the excluded clients, breaking the
310
    /// [`ClientGrid`] built from this collection. Drop dead clients
311
    /// via [`Clients::retain`] before retiling.
312
    ///
313
    /// # Arguments
314
    ///
315
    /// * `valid_pids` - PIDs of the clients that will be tiled, in the
316
    ///                  order they will be passed to
317
    ///                  [`arrange_client_window`].
318
1
    fn reset_tile_layout(&mut self, valid_pids: &[u32]) {
319
1
        debug_assert_eq!(
320
1
            valid_pids.len(),
321
1
            self.list.len(),
322
            "reset_tile_layout must receive every tracked client; \
323
             call Clients::retain to drop dead entries first",
324
        );
325
4
        for (index, pid) in 
valid_pids1
.
iter1
().
enumerate1
() {
326
4
            if let Some(&list_index) = self.pid_index.get(pid) {
327
4
                self.list[list_index].tile_index = index;
328
4
            
}0
329
        }
330
1
        self.layout_n = valid_pids.len();
331
1
    }
332
333
    /// Returns a reference to the client with the given process id, if any.
334
    ///
335
    /// # Arguments
336
    ///
337
    /// * `pid` - The process id of the client to look up.
338
    ///
339
    /// # Returns
340
    ///
341
    /// `Some(&Client)` if a client with the given PID exists, `None` otherwise.
342
39
    fn get_by_pid(&self, pid: u32) -> Option<&Client> {
343
39
        return self
344
39
            .pid_index
345
39
            .get(&pid)
346
39
            .map(|&index| return 
&self.list[index]34
);
347
39
    }
348
349
    /// Retains only the clients for which the predicate returns `true`,
350
    /// rebuilding the PID index to reflect the new positions.
351
    ///
352
    /// # Arguments
353
    ///
354
    /// * `f` - Predicate applied to each [`Client`]; kept when it returns `true`.
355
5
    fn retain<F: FnMut(&Client) -> bool>(&mut self, mut f: F) {
356
17
        
self.list5
.
retain5
(|client| return f(client));
357
5
        self.pid_index.clear();
358
10
        for (index, client) in 
self.list.iter()5
.
enumerate5
() {
359
10
            self.pid_index.insert(client.process_id, index);
360
10
        }
361
5
    }
362
}
363
364
/// Allows treating a [`Clients`] collection as a `&[Client]`, so callers can
365
/// use `&clients` where a slice is expected and get slice methods
366
/// (`iter`, `len`, `is_empty`, ...) via deref coercion.
367
impl std::ops::Deref for Clients {
368
    type Target = [Client];
369
370
46
    fn deref(&self) -> &[Client] {
371
46
        return &self.list;
372
46
    }
373
}
374
375
/// Consumes the collection and yields its clients in insertion order.
376
///
377
/// Used when merging a freshly launched [`Clients`] batch into an existing
378
/// collection while also spawning per-client pipe servers.
379
impl IntoIterator for Clients {
380
    type Item = Client;
381
    type IntoIter = std::vec::IntoIter<Client>;
382
383
0
    fn into_iter(self) -> Self::IntoIter {
384
0
        return self.list.into_iter();
385
0
    }
386
}
387
388
/// Hacky wrapper around a window handle.
389
///
390
/// As we cannot implement foreign traits for foreign structs
391
/// we introduce this wrapper to implement [Send] for [HWND].
392
#[derive(Debug, Eq)]
393
struct HWNDWrapper {
394
    hwdn: HWND,
395
}
396
397
unsafe impl Send for HWNDWrapper {}
398
399
impl PartialEq for HWNDWrapper {
400
    /// Returns whether to `HWNDWrapper` instances are equal or not
401
    /// based on the [HWND] they wrap.
402
2
    fn eq(&self, other: &Self) -> bool {
403
2
        return self.hwdn == other.hwdn;
404
2
    }
405
}
406
407
/// Returns a window handle to the current console window.
408
///
409
/// The [HWND] is wrapped in a `HWNDWrapper` so that
410
/// we can pass it inbetween threads.
411
0
fn get_console_window_wrapper(api: &dyn WindowsApi) -> HWNDWrapper {
412
0
    return HWNDWrapper {
413
0
        hwdn: api.get_console_window(),
414
0
    };
415
0
}
416
417
/// Returns a window handle to the foreground window.
418
///
419
/// The [HWND] is wrapped in a `HWNDWrapper` so that
420
/// we can pass it inbetween threads.
421
0
fn get_foreground_window_wrapper(api: &dyn WindowsApi) -> HWNDWrapper {
422
0
    return HWNDWrapper {
423
0
        hwdn: api.get_foreground_window(),
424
0
    };
425
0
}
426
427
/// Enum of all possible control mode states.
428
#[derive(PartialEq, Debug)]
429
enum ControlModeState {
430
    /// Controle mode is inactive.
431
    Inactive,
432
    /// One of the keys required for the control mode key combination
433
    /// is currently being pressed.
434
    Initiated,
435
    /// All required keys for the control mode key combination were pressed
436
    /// and control mode is now active.
437
    ///
438
    /// Active control mode prevents any input records from being sent to clients.
439
    Active,
440
    /// The user opened the `[e]nable/disable input` submenu from
441
    /// [`ControlModeState::Active`]; left only via `Esc`, which exits
442
    /// control mode entirely. `highlighted_pid` is the currently selected
443
    /// client (`None` when the cluster is empty); tracking by PID survives
444
    /// background-monitor `retain`s while the submenu is open.
445
    ///
446
    /// `anchor_col` is the upper-grid column carried across vertical
447
    /// moves so a Down + Up roundtrip across the partial-last-row
448
    /// boundary returns to the start cell. `None` while
449
    /// `highlighted_pid` is `None`.
450
    EnableDisableSubmenu {
451
        /// PID of the highlighted client, or `None` for an empty cluster.
452
        highlighted_pid: Option<u32>,
453
        /// Anchor upper-grid column carried across vertical moves.
454
        anchor_col: Option<i32>,
455
    },
456
}
457
458
/// The daemon is responsible to launch a client for
459
/// each host, positioning the client windows, forwarding
460
/// input records to all clients and handling control mode.
461
struct Daemon<'a> {
462
    /// A list of hostnames to connect to.
463
    hosts: Vec<String>,
464
    /// A username to use to connect to all clients.
465
    ///
466
    /// If it is empty the clients will use the SSH config to find an approriate
467
    /// username.
468
    username: Option<String>,
469
    /// Optional port used for all SSH connections.
470
    port: Option<u16>,
471
    /// The `DaemonConfig` that controls how the daemon console window looks like.
472
    config: &'a DaemonConfig,
473
    /// List of available cluster tags
474
    clusters: &'a [Cluster],
475
    /// The current control mode state. The submenu's selected client
476
    /// is carried inline on
477
    /// [`ControlModeState::EnableDisableSubmenu`] - tying its
478
    /// lifetime to the variant guarantees no stale highlight survives
479
    /// after `Esc`.
480
    control_mode_state: ControlModeState,
481
    /// If debug mode is enabled on the daemon it will also be enabled on all
482
    /// clients.
483
    debug: bool,
484
}
485
486
/// Compute the next submenu selection given a grid step.
487
///
488
/// Re-anchors on the first surviving client when `current_pid` is no
489
/// longer present (retained out while the submenu was open).
490
///
491
/// # Arguments
492
///
493
/// * `grid`        - Spatial grid view over the currently tracked clients.
494
/// * `current_pid` - PID currently highlighted, or `None`.
495
/// * `anchor_col`  - Anchor column carried from earlier moves.
496
/// * `direction`   - Direction the navigation keystroke encoded.
497
/// * `edge`        - Behavior when the move would leave the grid.
498
///
499
/// # Returns
500
///
501
/// `(new_pid, new_anchor_col)` to apply, or `(None, None)` for an empty
502
/// cluster.
503
33
fn next_submenu_selection(
504
33
    grid: &ClientGrid,
505
33
    current_pid: Option<u32>,
506
33
    anchor_col: Option<i32>,
507
33
    direction: NavigationDirection,
508
33
    edge: EdgeBehavior,
509
33
) -> (Option<u32>, Option<i32>) {
510
33
    if grid.is_empty() {
511
8
        return (None, None);
512
25
    }
513
25
    let 
current_pid23
= match current_pid.and_then(|pid| return grid.cell(pid)) {
514
23
        Some(cell) => cell.pid,
515
        None => {
516
2
            let first = grid.top_left_pid();
517
2
            let first_anchor = first
518
2
                .and_then(|pid| return grid.cell(pid))
519
2
                .map(|c| return grid.anchor_for(c));
520
2
            return (first, first_anchor);
521
        }
522
    };
523
23
    let anchor = anchor_col.unwrap_or_else(|| 
{0
524
0
        return grid
525
0
            .cell(current_pid)
526
0
            .map(|c| return grid.anchor_for(c))
527
0
            .unwrap_or(0);
528
0
    });
529
23
    return match grid.step(current_pid, anchor, direction, edge) {
530
23
        Some((pid, new_anchor)) => (Some(pid), Some(new_anchor)),
531
0
        None => (Some(current_pid), Some(anchor)),
532
    };
533
33
}
534
535
/// Build a [`ClientGrid`] from `clients` and `workspace_area` using the
536
/// same aspect-ratio expression the tiler uses.
537
///
538
/// # Arguments
539
///
540
/// * `clients`                   - Currently tracked clients in launch order.
541
/// * `workspace_area`            - Available workspace minus the daemon console.
542
/// * `aspect_ratio_adjustment`   - The `aspect_ratio_adjustment` daemon config.
543
///
544
/// # Returns
545
///
546
/// A populated [`ClientGrid`].
547
3
fn build_client_grid(
548
3
    clients: &Clients,
549
3
    workspace_area: &workspace::WorkspaceArea,
550
3
    aspect_ratio_adjustment: f64,
551
3
) -> ClientGrid {
552
3
    let aspect = workspace_aspect_ratio(workspace_area);
553
3
    let layout_n = clients.layout_n as i32;
554
3
    let (cols, rows) = grid_dimensions(layout_n, aspect, aspect_ratio_adjustment);
555
3
    let cells: Vec<(u32, usize)> = clients
556
3
        .iter()
557
7
        .
map3
(|c| return (c.process_id, c.tile_index))
558
3
        .collect();
559
3
    return ClientGrid::from_tiled_pids(&cells, layout_n, cols, rows);
560
3
}
561
562
impl<'a> Daemon<'a> {
563
    /// Builds a minimal [`Daemon`] suitable for unit tests.
564
    ///
565
    /// Populates every field with defaults that do not touch the
566
    /// Windows API or the network. Tests pick the
567
    /// [`ControlModeState`] they need to exercise; everything else
568
    /// stays inert.
569
    #[cfg(test)]
570
16
    fn for_test(
571
16
        config: &'a DaemonConfig,
572
16
        clusters: &'a [Cluster],
573
16
        control_mode_state: ControlModeState,
574
16
    ) -> Self {
575
16
        return Self {
576
16
            hosts: Vec::new(),
577
16
            username: None,
578
16
            port: None,
579
16
            config,
580
16
            clusters,
581
16
            control_mode_state,
582
16
            debug: false,
583
16
        };
584
16
    }
585
586
    /// Launches all client windows and blocks on the main run loop.
587
    ///
588
    /// Sets up the daemon console by disabling processed input mode and applying
589
    /// the configured colors and dimensions.
590
    /// Once all client windows have successfully started the daemon console window
591
    /// is moved to the foreground and receives focus.
592
0
    async fn launch<W: WindowsApi + Clone + 'static>(mut self, windows_api: &W) {
593
0
        windows_api
594
0
            .set_console_title(format!("{PKG_NAME} daemon").as_str())
595
0
            .unwrap();
596
0
        set_console_color(
597
0
            windows_api,
598
0
            CONSOLE_CHARACTER_ATTRIBUTES(self.config.console_color),
599
        );
600
0
        set_console_border_color(windows_api, COLORREF(0x000000FF));
601
602
0
        toggle_processed_input_mode(windows_api); // Disable processed input mode
603
604
0
        let workspace_area = workspace::get_workspace_area(windows_api, self.config.height);
605
606
0
        self.arrange_daemon_console(windows_api, &workspace_area);
607
608
        // Looks like on windows 10 re-arranging the console resets the console output buffer
609
0
        set_console_color(
610
0
            windows_api,
611
0
            CONSOLE_CHARACTER_ATTRIBUTES(self.config.console_color),
612
        );
613
614
0
        let mut clients = Arc::new(Mutex::new(
615
0
            launch_clients(
616
0
                windows_api,
617
0
                self.hosts.to_vec(),
618
0
                &self.username,
619
0
                self.port,
620
0
                self.debug,
621
0
                &workspace_area,
622
0
                self.config.aspect_ratio_adjustment,
623
0
                0,
624
0
            )
625
0
            .await,
626
        ));
627
628
        // Now that all clients started, focus the daemon console again.
629
0
        let daemon_console = windows_api.get_console_window();
630
0
        let _ = windows_api.bring_window_to_top(daemon_console, true);
631
632
0
        self.print_instructions(windows_api);
633
0
        self.run(windows_api, &mut clients, &workspace_area).await;
634
0
    }
635
636
    /// The main run loop of the `daemon` subcommand.
637
    ///
638
    /// Opens a multi-producer, multi-consumer broadcasting channel used to
639
    /// send the read input records in parallel to the name pipe servers
640
    /// the clients are listening on.
641
    /// Spawns a background thread that waits for all clients to terminate
642
    /// and then stops the current process.
643
    /// Spawns a background thread that ensures the z-order of all client
644
    /// windows is in sync with the daemon window.
645
    /// I.e. if the daemon window is focussed, all clients should be moved to the foreground.
646
    ///
647
    /// The main loop consists of waiting for input records to read from the keyboard,
648
    /// sending them to all clients and handling control mode.
649
    ///
650
    /// # Arguments
651
    ///
652
    /// * `windows_api`                     - The Windows API implementation to use
653
    /// * `clients`                         - A thread safe mapping from the number
654
    ///                                       a client console window was launched at
655
    ///                                       in relation to the other client windows
656
    ///                                       and the clients console window handle.
657
    /// * `workspace_area`                  - The available workspace area on the
658
    ///                                       primary monitor minus the space occupied
659
    ///                                       by the daemon console window.
660
0
    async fn run<W: WindowsApi + Clone + 'static>(
661
0
        &mut self,
662
0
        windows_api: &W,
663
0
        clients: &mut Arc<Mutex<Clients>>,
664
0
        workspace_area: &workspace::WorkspaceArea,
665
0
    ) {
666
0
        let (sender, _) =
667
0
            broadcast::channel::<[u8; SERIALIZED_INPUT_RECORD_0_LENGTH]>(SENDER_CAPACITY);
668
669
0
        let mut servers = Arc::new(Mutex::new(
670
0
            self.launch_named_pipe_servers(&sender, Arc::clone(clients)),
671
        ));
672
673
        // Monitor client processes
674
0
        let clients_clone = Arc::clone(clients);
675
0
        let windows_api_clone = windows_api.clone();
676
0
        tokio::spawn(async move {
677
            loop {
678
0
                clients_clone.lock().unwrap().retain(|client| {
679
0
                    match windows_api_clone.get_exit_code(client.process_handle) {
680
0
                        Ok(exit_code) => return exit_code == STILL_ACTIVE.0 as u32,
681
0
                        Err(_) => return false, // Process handle is invalid, remove client
682
                    }
683
0
                });
684
0
                if clients_clone.lock().unwrap().is_empty() {
685
                    // All clients have exited, exit the daemon as well
686
0
                    std::process::exit(0);
687
0
                }
688
0
                tokio::time::sleep(Duration::from_millis(5)).await;
689
            }
690
        });
691
692
0
        ensure_client_z_order_in_sync_with_daemon(
693
0
            Arc::new(windows_api.clone()),
694
0
            clients.to_owned(),
695
        );
696
697
        loop {
698
0
            self.handle_input_record(
699
0
                windows_api,
700
0
                &sender,
701
0
                read_keyboard_input(windows_api),
702
0
                clients,
703
0
                workspace_area,
704
0
                &mut servers,
705
0
            )
706
0
            .await;
707
        }
708
    }
709
710
    /// Launch a named pipe server for each host in a dedicated thread.
711
    ///
712
    /// # Arguments
713
    ///
714
    /// * `sender` - The sender end of the broadcast channel through which
715
    ///              the main thread will send the input records that are to
716
    ///              be forwarded to the clients.
717
    ///
718
    /// # Returns
719
    ///
720
    /// Returns a list of [JoinHandle]s, one handle for each thread.
721
0
    fn launch_named_pipe_servers(
722
0
        &self,
723
0
        sender: &Sender<[u8; SERIALIZED_INPUT_RECORD_0_LENGTH]>,
724
0
        clients: Arc<Mutex<Clients>>,
725
0
    ) -> Vec<JoinHandle<()>> {
726
0
        let mut servers: Vec<JoinHandle<()>> = Vec::new();
727
0
        for _ in &self.hosts {
728
0
            self.launch_named_pipe_server(&mut servers, sender, Arc::clone(&clients));
729
0
        }
730
0
        return servers;
731
0
    }
732
733
    /// Launch a named pipe server in a dedicated thread.
734
    ///
735
    /// # Arguments
736
    ///
737
    /// * `servers` - A list of [JoinHandle]s to which the join handle for
738
    ///               the new thread will be added.
739
    /// * `sender`  - The sender end of the broadcast channel through which
740
    ///               the main thread will send the input records that are to
741
    ///               be forwarded to the clients.
742
0
    fn launch_named_pipe_server(
743
0
        &self,
744
0
        servers: &mut Vec<JoinHandle<()>>,
745
0
        sender: &Sender<[u8; SERIALIZED_INPUT_RECORD_0_LENGTH]>,
746
0
        clients: Arc<Mutex<Clients>>,
747
0
    ) {
748
0
        let named_pipe_server = ServerOptions::new()
749
0
            .access_inbound(true)
750
0
            .access_outbound(true)
751
0
            .pipe_mode(PipeMode::Message)
752
0
            .create(PIPE_NAME)
753
0
            .unwrap_or_else(|err| {
754
0
                error!("{}", err);
755
0
                panic!("Failed to create named pipe server",)
756
            });
757
0
        let mut receiver = sender.subscribe();
758
0
        servers.push(tokio::spawn(async move {
759
0
            named_pipe_server_routine(named_pipe_server, &mut receiver, clients).await;
760
0
        }));
761
0
    }
762
763
    /// Handle the given input record.
764
    ///
765
    /// Input records are being forwarded to all clients.
766
    /// If a sequence of input records matches the control mode
767
    /// key combination, forwarding is temporarily interrupted,
768
    /// until control mode is exited.
769
    ///
770
    /// # Arguments
771
    ///
772
    /// * `sender`                          - The sender end of the broadcast channel
773
    ///                                       through which we will send the input records
774
    ///                                       that are being forwarded to the clients
775
    ///                                       by the named pipe servers (`servers`).
776
    /// * `input_record`                    - The [INPUT_RECORD_0].`KeyEvent` read from the
777
    ///                                       console input buffer.
778
    /// * `clients`                         - A thread safe mapping from the number
779
    ///                                       a client console window was launched at
780
    ///                                       in relation to the other client windows
781
    ///                                       and the clients console window handle.
782
    ///                                       The mapping will be extended if additional clients
783
    ///                                       are being added through control mode `[c]reate window(s)`.
784
    /// * `workspace_area`                  - The available workspace area on the
785
    ///                                       primary monitor minus the space occupied
786
    ///                                       by the daemon console window.
787
    /// * `servers`                         - A thread safe list of [JoinHandle]s,
788
    ///                                       one handle for each named pipe server background thread.
789
    ///                                       The list will be extended if additional clients are being added
790
    ///                                       through control mode `[c]reate window(s)`.
791
0
    async fn handle_input_record<W: WindowsApi + Clone + 'static>(
792
0
        &mut self,
793
0
        windows_api: &W,
794
0
        sender: &Sender<[u8; SERIALIZED_INPUT_RECORD_0_LENGTH]>,
795
0
        input_record: INPUT_RECORD_0,
796
0
        clients: &mut Arc<Mutex<Clients>>,
797
0
        workspace_area: &workspace::WorkspaceArea,
798
0
        servers: &mut Arc<Mutex<Vec<JoinHandle<()>>>>,
799
0
    ) {
800
0
        if self.control_mode_is_active(windows_api, clients, input_record) {
801
0
            if self.control_mode_state == ControlModeState::Initiated {
802
0
                clear_screen(windows_api);
803
0
                println!("Control Mode (Esc to exit)");
804
0
                println!(
805
                    "[c]reate window(s), [r]etile, [e]nable/disable input, [t]oggle enabled, e[n]able all, copy active [h]ostname(s)"
806
                );
807
0
                self.control_mode_state = ControlModeState::Active;
808
0
                return;
809
0
            }
810
0
            let key_event = unsafe { input_record.KeyEvent };
811
0
            if !key_event.bKeyDown.as_bool() {
812
0
                return;
813
0
            }
814
0
            if matches!(
815
0
                self.control_mode_state,
816
                ControlModeState::EnableDisableSubmenu { .. }
817
            ) {
818
0
                self.handle_enable_disable_submenu_key(
819
0
                    windows_api,
820
0
                    clients,
821
0
                    workspace_area,
822
0
                    key_event,
823
                );
824
0
                return;
825
0
            }
826
0
            match classify_control_mode_key(
827
0
                VIRTUAL_KEY(key_event.wVirtualKeyCode),
828
0
                key_event.dwControlKeyState,
829
0
            ) {
830
0
                ControlModeAction::Retile => {
831
0
                    self.rearrange_client_windows(
832
0
                        windows_api,
833
0
                        &mut clients.lock().unwrap(),
834
0
                        workspace_area,
835
0
                    );
836
0
                    self.arrange_daemon_console(windows_api, workspace_area);
837
0
                }
838
                ControlModeAction::OpenEnableDisableSubmenu => {
839
0
                    let clients_guard = clients.lock().unwrap();
840
0
                    let grid = build_client_grid(
841
0
                        &clients_guard,
842
0
                        workspace_area,
843
0
                        self.config.aspect_ratio_adjustment,
844
                    );
845
0
                    let next_pid = grid.top_left_pid();
846
0
                    let anchor_col = next_pid
847
0
                        .and_then(|p| return grid.cell(p))
848
0
                        .map(|c| return grid.anchor_for(c));
849
0
                    self.apply_submenu_highlight(&clients_guard, None, next_pid);
850
0
                    self.control_mode_state = ControlModeState::EnableDisableSubmenu {
851
0
                        highlighted_pid: next_pid,
852
0
                        anchor_col,
853
0
                    };
854
0
                    self.render_enable_disable_submenu(windows_api);
855
                }
856
                ControlModeAction::ToggleEnabled => {
857
                    // Snapshot before flipping so each client toggles relative
858
                    // to its own pre-loop state, not to writes this loop has
859
                    // already made.
860
0
                    self.update_client_states(clients, |clients_guard| {
861
0
                        return clients_guard
862
0
                            .iter()
863
0
                            .map(|client| {
864
0
                                let flipped = match *client.state_sender.borrow() {
865
0
                                    ClientState::Active => ClientState::Disabled,
866
0
                                    ClientState::Disabled => ClientState::Active,
867
                                };
868
0
                                return (client.process_id, flipped);
869
0
                            })
870
0
                            .collect();
871
0
                    });
872
0
                    self.quit_control_mode(windows_api);
873
                }
874
                ControlModeAction::EnableAll => {
875
0
                    self.update_client_states(clients, |clients_guard| {
876
0
                        return clients_guard
877
0
                            .iter()
878
0
                            .map(|client| return (client.process_id, ClientState::Active))
879
0
                            .collect();
880
0
                    });
881
0
                    self.quit_control_mode(windows_api);
882
                }
883
                ControlModeAction::CreateWindows => {
884
0
                    clear_screen(windows_api);
885
                    // TODO: make ESC abort
886
0
                    println!("Hostname(s) or cluster tag(s): (leave empty to abort)");
887
0
                    toggle_processed_input_mode(windows_api); // As it was disabled before, this enables it again
888
0
                    let mut hostnames = String::new();
889
0
                    match io::stdin().read_line(&mut hostnames) {
890
0
                        Ok(2) => {
891
0
                            // Empty input (only newline '\n')
892
0
                        }
893
                        Ok(_) => {
894
0
                            let number_of_existing_clients = clients.lock().unwrap().len();
895
0
                            let new_clients = launch_clients(
896
0
                                windows_api,
897
0
                                expand_hosts(
898
0
                                    hostnames.split(' ').map(|x| return x.trim()).collect(),
899
0
                                    self.clusters,
900
                                ),
901
0
                                &self.username,
902
0
                                self.port,
903
0
                                self.debug,
904
0
                                workspace_area,
905
0
                                self.config.aspect_ratio_adjustment,
906
0
                                number_of_existing_clients,
907
                            )
908
0
                            .await;
909
0
                            for client in new_clients.into_iter() {
910
0
                                clients.lock().unwrap().push(client);
911
0
                                self.launch_named_pipe_server(
912
0
                                    &mut servers.lock().unwrap(),
913
0
                                    sender,
914
0
                                    Arc::clone(clients),
915
0
                                );
916
0
                            }
917
                        }
918
0
                        Err(error) => {
919
0
                            error!("{error}");
920
                        }
921
                    }
922
0
                    toggle_processed_input_mode(windows_api); // Re-disable processed input mode.
923
0
                    self.rearrange_client_windows(
924
0
                        windows_api,
925
0
                        &mut clients.lock().unwrap(),
926
0
                        workspace_area,
927
                    );
928
0
                    self.arrange_daemon_console(windows_api, workspace_area);
929
                    // Focus the daemon console again.
930
0
                    let daemon_window = windows_api.get_console_window();
931
0
                    let _ = windows_api.bring_window_to_top(daemon_window, true);
932
0
                    self.quit_control_mode(windows_api);
933
                }
934
                ControlModeAction::CopyHostnames => {
935
0
                    let mut active_hostnames: Vec<String> = vec![];
936
0
                    for client in clients.lock().unwrap().iter() {
937
0
                        if windows_api.is_window(client.window_handle) {
938
0
                            active_hostnames.push(client.hostname.clone());
939
0
                        }
940
                    }
941
0
                    cli_clipboard::set_contents(active_hostnames.join(" ")).unwrap();
942
0
                    self.quit_control_mode(windows_api);
943
                }
944
0
                ControlModeAction::NoOp => {}
945
            }
946
0
            return;
947
0
        }
948
0
        let error_handler = |err| {
949
0
            error!("{}", err);
950
0
            panic!(
951
                "Failed to serialize input recored `{}`",
952
0
                input_record.string_repr()
953
            )
954
        };
955
0
        match sender.send(
956
0
            serialize_input_record_0(&input_record)[..]
957
0
                .try_into()
958
0
                .unwrap_or_else(error_handler),
959
0
        ) {
960
0
            Ok(_) => {}
961
0
            Err(_) => {
962
0
                thread::sleep(time::Duration::from_nanos(1));
963
0
            }
964
        }
965
0
    }
966
967
    /// Updates `self.control_mode_state` for the given input record and
968
    /// reports whether control mode owned the keystroke.
969
    ///
970
    /// Entering control mode requires this function to be called twice
971
    /// because the activating chord `Ctrl + A` produces two input
972
    /// records (the modifier press and the `A` key). Once active, every
973
    /// subsequent key - including the `Esc` that exits control mode -
974
    /// is reported as consumed so callers do not forward it to clients.
975
    ///
976
    /// # Arguments
977
    ///
978
    /// * `windows_api`  - The Windows API implementation to use.
979
    /// * `clients`      - Currently tracked clients. Used to clear the
980
    ///                    submenu highlight on the previously-selected
981
    ///                    client when `Esc` exits the enable/disable
982
    ///                    submenu.
983
    /// * `input_record` - A KeyEvent input record.
984
    ///
985
    /// # Returns
986
    ///
987
    /// Whether the input record was consumed by control mode. Returns
988
    /// `true` while control mode is active (including the `Esc`
989
    /// keystroke that exits it), so callers must not forward such
990
    /// records to clients.
991
1
    fn control_mode_is_active<W: WindowsApi>(
992
1
        &mut self,
993
1
        windows_api: &W,
994
1
        clients: &Mutex<Clients>,
995
1
        input_record: INPUT_RECORD_0,
996
1
    ) -> bool {
997
1
        let key_event = unsafe { input_record.KeyEvent };
998
1
        if self.control_mode_state == ControlModeState::Active
999
0
            || matches!(
1000
0
                self.control_mode_state,
1001
                ControlModeState::EnableDisableSubmenu { .. }
1002
            )
1003
        {
1004
1
            if key_event.wVirtualKeyCode == VK_ESCAPE.0 {
1005
                if let ControlModeState::EnableDisableSubmenu {
1006
0
                    highlighted_pid, ..
1007
1
                } = self.control_mode_state
1008
0
                {
1009
0
                    let clients_guard = clients.lock().unwrap();
1010
0
                    self.apply_submenu_highlight(&clients_guard, highlighted_pid, None);
1011
1
                }
1012
1
                self.quit_control_mode(windows_api);
1013
1
                return true;
1014
0
            }
1015
0
            return true;
1016
0
        }
1017
0
        if (key_event.dwControlKeyState & LEFT_CTRL_PRESSED >= 1
1018
0
            || key_event.dwControlKeyState & RIGHT_CTRL_PRESSED >= 1)
1019
0
            && key_event.wVirtualKeyCode == VK_A.0
1020
        {
1021
0
            self.control_mode_state = ControlModeState::Initiated;
1022
0
            return true;
1023
0
        }
1024
0
        return false;
1025
1
    }
1026
1027
    /// Prints the default daemon instructions to the daemon console.
1028
    ///
1029
    /// # Arguments
1030
    ///
1031
    /// * `windows_api` - Windows API used to clear and redraw the
1032
    ///                   daemon console.
1033
2
    fn quit_control_mode<W: WindowsApi>(&mut self, windows_api: &W) {
1034
2
        self.print_instructions(windows_api);
1035
2
        self.control_mode_state = ControlModeState::Inactive;
1036
2
    }
1037
1038
    /// Clears the console screen and prints the default daemon instructions.
1039
2
    fn print_instructions<W: WindowsApi>(&self, windows_api: &W) {
1040
2
        clear_screen(windows_api);
1041
2
        println!("Input to terminal: (Ctrl-A to enter control mode)");
1042
2
    }
1043
1044
    /// Iterates over all still open client windows and re-arranges them
1045
    /// on the screen based on the aspect ration adjustment daemon configuration.
1046
    ///
1047
    /// Client windows will be re-sized and re-positioned.
1048
    ///
1049
    /// # Arguments
1050
    ///
1051
    /// * `windows_api`                     - The Windows API implementation to use
1052
    /// * `clients`                         - A thread safe mapping from the number
1053
    ///                                       a client console window was launched at
1054
    ///                                       in relation to the other client windows
1055
    ///                                       and the clients console window handle.
1056
    ///                                       The number is relevant to determine the
1057
    ///                                       position on the screen the window should
1058
    ///                                       be placed at.
1059
    /// * `workspace_area`                  - The available workspace area on the
1060
    ///                                       primary monitor minus the space occupied
1061
    ///                                       by the daemon console window.
1062
0
    fn rearrange_client_windows<W: WindowsApi>(
1063
0
        &self,
1064
0
        windows_api: &W,
1065
0
        clients: &mut Clients,
1066
0
        workspace_area: &workspace::WorkspaceArea,
1067
0
    ) {
1068
0
        clients.retain(|client| {
1069
0
            let exit_code = match windows_api.get_exit_code(client.process_handle) {
1070
0
                Ok(code) => code,
1071
0
                Err(_) => return false,
1072
            };
1073
0
            return exit_code == STILL_ACTIVE.0 as u32
1074
0
                && windows_api.is_window(client.window_handle);
1075
0
        });
1076
0
        let valid_layout: Vec<(u32, HWND)> = clients
1077
0
            .iter()
1078
0
            .map(|c| return (c.process_id, c.window_handle))
1079
0
            .collect();
1080
0
        let valid_pids: Vec<u32> = valid_layout.iter().map(|(pid, _)| return *pid).collect();
1081
0
        clients.reset_tile_layout(&valid_pids);
1082
0
        for (index, (_, window_handle)) in valid_layout.iter().enumerate() {
1083
0
            arrange_client_window(
1084
0
                windows_api,
1085
0
                window_handle,
1086
0
                workspace_area,
1087
0
                index,
1088
0
                valid_layout.len(),
1089
0
                self.config.aspect_ratio_adjustment,
1090
            )
1091
        }
1092
0
    }
1093
1094
    /// Dispatches a key press received while the daemon is in the
1095
    /// [`ControlModeState::EnableDisableSubmenu`] state. `[e]/[d]/[t]`
1096
    /// act on the currently selected client; `Navigate` moves the
1097
    /// selection and redraws the prompt. The submenu is left via
1098
    /// `ESC`, which is handled by the caller.
1099
    ///
1100
    /// # Arguments
1101
    ///
1102
    /// * `windows_api` - Windows API implementation used by the
1103
    ///                   render helper when redrawing after navigation.
1104
    /// * `clients`     - Shared client collection. Empty lists are a
1105
    ///                   no-op for every action.
1106
    /// * `key_event`   - The key-down [`KEY_EVENT_RECORD`] dispatched
1107
    ///                   from `handle_input_record`.
1108
11
    fn handle_enable_disable_submenu_key<W: WindowsApi>(
1109
11
        &mut self,
1110
11
        windows_api: &W,
1111
11
        clients: &Mutex<Clients>,
1112
11
        workspace_area: &workspace::WorkspaceArea,
1113
11
        key_event: KEY_EVENT_RECORD,
1114
11
    ) {
1115
        let ControlModeState::EnableDisableSubmenu {
1116
11
            highlighted_pid,
1117
11
            anchor_col,
1118
11
        } = self.control_mode_state
1119
        else {
1120
0
            return;
1121
        };
1122
11
        match classify_enable_disable_submenu_key(
1123
11
            VIRTUAL_KEY(key_event.wVirtualKeyCode),
1124
11
            key_event.dwControlKeyState,
1125
11
        ) {
1126
            EnableDisableSubmenuAction::Enable => {
1127
4
                self.update_client_states(clients, |clients_guard| {
1128
4
                    return highlighted_pid
1129
4
                        .and_then(|pid| return 
clients_guard3
.
get_by_pid3
(
pid3
))
1130
4
                        .map(|client| return 
vec!3
[
(client.process_id, ClientState::Active)3
])
1131
4
                        .unwrap_or_default();
1132
4
                });
1133
            }
1134
            EnableDisableSubmenuAction::Disable => {
1135
1
                self.update_client_states(clients, |clients_guard| {
1136
1
                    return highlighted_pid
1137
1
                        .and_then(|pid| return clients_guard.get_by_pid(pid))
1138
1
                        .map(|client| return vec![(client.process_id, ClientState::Disabled)])
1139
1
                        .unwrap_or_default();
1140
1
                });
1141
            }
1142
            EnableDisableSubmenuAction::Toggle => {
1143
2
                self.update_client_states(clients, |clients_guard| {
1144
2
                    return highlighted_pid
1145
2
                        .and_then(|pid| return clients_guard.get_by_pid(pid))
1146
2
                        .map(|client| {
1147
2
                            let flipped = match *client.state_sender.borrow() {
1148
1
                                ClientState::Active => ClientState::Disabled,
1149
1
                                ClientState::Disabled => ClientState::Active,
1150
                            };
1151
2
                            return vec![(client.process_id, flipped)];
1152
2
                        })
1153
2
                        .unwrap_or_default();
1154
2
                });
1155
            }
1156
3
            EnableDisableSubmenuAction::Navigate(direction) => {
1157
3
                let clients_guard = clients.lock().unwrap();
1158
3
                let grid = build_client_grid(
1159
3
                    &clients_guard,
1160
3
                    workspace_area,
1161
3
                    self.config.aspect_ratio_adjustment,
1162
3
                );
1163
3
                let (next_pid, next_anchor) = next_submenu_selection(
1164
3
                    &grid,
1165
3
                    highlighted_pid,
1166
3
                    anchor_col,
1167
3
                    direction,
1168
3
                    self.config.submenu_edge_behavior,
1169
3
                );
1170
3
                self.apply_submenu_highlight(&clients_guard, highlighted_pid, next_pid);
1171
3
                self.control_mode_state = ControlModeState::EnableDisableSubmenu {
1172
3
                    highlighted_pid: next_pid,
1173
3
                    anchor_col: next_anchor,
1174
3
                };
1175
3
                self.render_enable_disable_submenu(windows_api);
1176
3
            }
1177
1
            EnableDisableSubmenuAction::NoOp => {}
1178
        }
1179
11
    }
1180
1181
    /// Redraws the enable/disable submenu prompt.
1182
    ///
1183
    /// # Arguments
1184
    ///
1185
    /// * `windows_api` - Windows API used to clear the console.
1186
3
    fn render_enable_disable_submenu<W: WindowsApi>(&self, windows_api: &W) {
1187
3
        clear_screen(windows_api);
1188
3
        println!("Enable/Disable input (Esc to exit)");
1189
3
        println!("[e]nable, [d]isable, [t]oggle, arrows/hjkl to move");
1190
3
    }
1191
1192
    /// Move the per-client highlight from `prev_pid` to `next_pid`.
1193
    ///
1194
    /// PID-based clearing tolerates the background monitor's `retain`
1195
    /// shifting indices while the submenu is open.
1196
    ///
1197
    /// # Arguments
1198
    ///
1199
    /// * `clients`  - Currently tracked clients.
1200
    /// * `prev_pid` - PID currently highlighted, or `None` if no
1201
    ///                client is highlighted.
1202
    /// * `next_pid` - PID to highlight now, or `None` to clear the
1203
    ///                highlight entirely.
1204
7
    fn apply_submenu_highlight(
1205
7
        &self,
1206
7
        clients: &Clients,
1207
7
        prev_pid: Option<u32>,
1208
7
        next_pid: Option<u32>,
1209
7
    ) {
1210
7
        if let Some(
prev_pid6
) = prev_pid {
1211
6
            if Some(prev_pid) != next_pid {
1212
6
                if let Some(
prev_client4
) = clients.get_by_pid(prev_pid) {
1213
4
                    prev_client.highlight_sender.send_replace(false);
1214
4
                
}2
1215
0
            }
1216
1
        }
1217
7
        if let Some(
next_pid6
) = next_pid {
1218
6
            if let Some(client) = clients.get_by_pid(next_pid) {
1219
6
                client.highlight_sender.send_replace(true);
1220
6
            
}0
1221
1
        }
1222
7
    }
1223
1224
    /// Apply a batch of [`ClientState`] updates while holding the
1225
    /// [`Clients`] mutex exactly once.
1226
    ///
1227
    /// `f` is called with the locked guard and returns the list of
1228
    /// `(pid, new_state)` updates to apply. The guard is held across both
1229
    /// the build and the apply phase so callers see a stable snapshot.
1230
    ///
1231
    /// # Arguments
1232
    ///
1233
    /// * `clients` - Shared client collection.
1234
    /// * `f`       - Builds the updates from a `&Clients` snapshot.
1235
7
    fn update_client_states<F>(&self, clients: &Mutex<Clients>, f: F)
1236
7
    where
1237
7
        F: FnOnce(&Clients) -> Vec<(u32, ClientState)>,
1238
    {
1239
7
        let clients_guard = clients.lock().unwrap();
1240
7
        let updates = f(&clients_guard);
1241
7
        for (
pid6
,
state6
) in updates {
1242
6
            self.set_client_state(&clients_guard, pid, state);
1243
6
        }
1244
7
    }
1245
1246
    /// Push a new [`ClientState`] for the client identified by `pid`.
1247
    ///
1248
    /// Looks the client up by PID and broadcasts the new state through its
1249
    /// [`watch::Sender`]. The pipe-server task subscribed to that sender
1250
    /// observes the change and forwards a [`crate::protocol::TAG_STATE_CHANGE`]
1251
    /// frame to the client over the named pipe. Called from the
1252
    /// control-mode handlers for `[t]oggle enabled` and `e[n]able all` via
1253
    /// [`Daemon::update_client_states`].
1254
    ///
1255
    /// # Arguments
1256
    ///
1257
    /// * `clients` - The daemon's tracked clients.
1258
    /// * `pid`     - Process id of the client whose state should change.
1259
    /// * `state`   - The new state to broadcast.
1260
6
    fn set_client_state(&self, clients: &Clients, pid: u32, state: ClientState) {
1261
6
        if let Some(client) = clients.get_by_pid(pid) {
1262
6
            // `send_replace` always updates the stored value (unlike `send`,
1263
6
            // which returns `Err` and leaves the value untouched when there
1264
6
            // are no active receivers). This matters during the brief window
1265
6
            // between [`Client`] construction and the pipe-server task's
1266
6
            // `subscribe()`: any state change pushed in that window must
1267
6
            // still be visible to the next subscriber via `borrow`.
1268
6
            client.state_sender.send_replace(state);
1269
6
        
}0
1270
6
    }
1271
1272
    /// Re-sizes and re-positions the daemon console window on the screen
1273
    /// based on the daemon height configuration.
1274
    ///
1275
    /// # Arguments
1276
    ///
1277
    /// * `windows_api` - The Windows API implementation to use
1278
    /// * `workspace_area` - The available workspace area on the
1279
    ///                      primary monitor minus the space occupied
1280
    ///                      by the daemon console window.
1281
0
    fn arrange_daemon_console<W: WindowsApi>(
1282
0
        &self,
1283
0
        windows_api: &W,
1284
0
        workspace_area: &WorkspaceArea,
1285
0
    ) {
1286
0
        let (x, y, width, height) = get_console_rect(
1287
0
            0,
1288
0
            workspace_area.height,
1289
0
            workspace_area.width - (workspace_area.x_fixed_frame + workspace_area.x_size_frame),
1290
0
            self.config.height,
1291
0
            workspace_area,
1292
0
        );
1293
0
        arrange_console(windows_api, x, y, width, height);
1294
0
    }
1295
}
1296
1297
/// The processed console input mode controls whether special key combinations
1298
/// such as `Ctrl + c` or `Ctrl + BREAK` receive special handling or are treated
1299
/// as simple key presses.
1300
///
1301
/// By default processed input mode is enabled, meaning `Ctrl + c` is treated as
1302
/// a signal, not key presses.
1303
///
1304
/// <https://learn.microsoft.com/en-us/windows/console/ctrl-c-and-ctrl-break-signals>
1305
///
1306
/// # Arguments
1307
///
1308
/// * `windows_api` - The Windows API implementation to use
1309
0
fn toggle_processed_input_mode<W: WindowsApi>(windows_api: &W) {
1310
0
    let handle = get_console_input_buffer();
1311
0
    let mode = windows_api.get_console_mode(handle).unwrap();
1312
0
    let new_mode = windows::Win32::System::Console::CONSOLE_MODE(mode.0 ^ ENABLE_PROCESSED_INPUT.0);
1313
0
    let _ = windows_api.set_console_mode(handle, new_mode);
1314
0
}
1315
1316
/// Resolve cluster tags into hostnames
1317
///
1318
/// Iterates over the list of hosts to find and resolve cluster tags.
1319
/// Nested cluster tags are supported but recursivness is not checked for.
1320
///
1321
/// # Arguments
1322
///
1323
/// * `hosts`       - List of hosts including hostnames and or cluster tags
1324
/// * `clusters`    - List of available cluster tags
1325
///
1326
/// # Returns
1327
///
1328
/// A list of hostnames
1329
18
pub fn resolve_cluster_tags<'a>(hosts: Vec<&'a str>, clusters: &'a [Cluster]) -> Vec<&'a str> {
1330
18
    let mut resolved_hosts: Vec<&str> = Vec::new();
1331
    let mut is_cluster_tag: bool;
1332
31
    for host in 
hosts18
{
1333
31
        is_cluster_tag = false;
1334
31
        for 
cluster23
in clusters {
1335
23
            if host == cluster.name {
1336
5
                is_cluster_tag = true;
1337
5
                resolved_hosts.extend(resolve_cluster_tags(
1338
9
                    
cluster.hosts.iter()5
.
map5
(|host| return &**host).
collect5
(),
1339
5
                    clusters,
1340
                ));
1341
5
                break;
1342
18
            }
1343
        }
1344
31
        if !is_cluster_tag {
1345
26
            resolved_hosts.push(host);
1346
26
        
}5
1347
    }
1348
18
    return resolved_hosts;
1349
18
}
1350
1351
/// Resolve cluster tags in `hosts` and expand brace expressions
1352
/// (e.g. `host{1..3}.local`) in each resulting hostname.
1353
///
1354
/// Used by the control-mode `[c]reate window(s)` path so hostname
1355
/// input behaves the same as on the CLI. Each cluster-resolved
1356
/// hostname is passed through [`bracoxide::explode`] individually;
1357
/// hostnames that do not contain a brace expression are kept as-is.
1358
///
1359
/// # Arguments
1360
///
1361
/// * `hosts`    - User-supplied hostnames and/or cluster tags.
1362
/// * `clusters` - Available cluster definitions.
1363
///
1364
/// # Returns
1365
///
1366
/// The fully resolved, brace-expanded list of hostnames.
1367
4
pub fn expand_hosts(hosts: Vec<&str>, clusters: &[Cluster]) -> Vec<String> {
1368
4
    return resolve_cluster_tags(hosts, clusters)
1369
4
        .into_iter()
1370
7
        .
flat_map4
(|host| return explode(host).unwrap_or_else(|_| return
vec!4
[
host4
.
to_owned4
()]))
1371
4
        .collect();
1372
4
}
1373
1374
/// Launches a client console for each given host and waits for
1375
/// the client windows to exist before returning their handles.
1376
///
1377
/// # Arguments
1378
///
1379
/// * `windows_api`             - The Windows API implementation to use
1380
/// * `hosts`                   - List of hosts
1381
/// * `username`                - Optional username, if none is given
1382
///                               the client will use the SSH config to
1383
///                               determine a username.
1384
/// * `port`                    - Optional port for SSH connections
1385
/// * `debug`                   - Toggles debug mode on the client.
1386
/// * `workspace_area`          - The available workspace area on the primary monitor
1387
///                               minus the space occupied by the daemon console window.
1388
///                               Used to arrange the client window.
1389
/// * `aspect_ratio_adjustment` - The `aspect_ratio_adjustment` daemon configuration.
1390
///                               Used to arrange the client window.
1391
/// * `index_offset`            - Offset used to position the new windows correctly
1392
///                               from the start, avoiding flickering.
1393
///
1394
/// # Returns
1395
///
1396
/// A [`Clients`] collection preserving the launch order and indexed by
1397
/// process id for pipe-server correlation.
1398
0
async fn launch_clients<W: WindowsApi + 'static + Clone>(
1399
0
    windows_api: &W,
1400
0
    hosts: Vec<String>,
1401
0
    username: &Option<String>,
1402
0
    port: Option<u16>,
1403
0
    debug: bool,
1404
0
    workspace_area: &workspace::WorkspaceArea,
1405
0
    aspect_ratio_adjustment: f64,
1406
0
    index_offset: usize,
1407
0
) -> Clients {
1408
0
    let len_hosts = hosts.len();
1409
0
    let _guard = WindowsSettingsDefaultTerminalApplicationGuard::new();
1410
1411
    // Create an Arc to share the windows_api across parallel tasks
1412
0
    let windows_api_arc = Arc::new(windows_api.clone());
1413
1414
    // Create tasks for each client launch using spawn_blocking to handle the synchronous operations
1415
0
    let mut tasks = Vec::new();
1416
1417
0
    for (index, host) in hosts.into_iter().enumerate() {
1418
0
        let username_client = username.clone();
1419
0
        let workspace_area_client = *workspace_area;
1420
0
        let windows_api_clone = Arc::clone(&windows_api_arc);
1421
1422
        // Use spawn_blocking to run the synchronous launch_client_console in parallel
1423
0
        let task = tokio::task::spawn_blocking(move || {
1424
0
            let (window_handle, process_handle, process_id) = launch_client_console(
1425
0
                windows_api_clone.as_ref(),
1426
0
                &host,
1427
0
                username_client,
1428
0
                port,
1429
0
                debug,
1430
0
                index + index_offset,
1431
0
                &workspace_area_client,
1432
0
                len_hosts + index_offset,
1433
0
                aspect_ratio_adjustment,
1434
0
            );
1435
            // The receivers are dropped immediately; pipe-server tasks
1436
            // acquire their own receivers via `subscribe()` after PID
1437
            // correlation. Holding the senders on the [`Client`] keeps both
1438
            // channels alive for the lifetime of the client.
1439
0
            let (state_sender, _state_receiver) = watch::channel(ClientState::Active);
1440
0
            let (highlight_sender, _highlight_receiver) = watch::channel(false);
1441
0
            return (
1442
0
                index,
1443
0
                Client {
1444
0
                    hostname: host,
1445
0
                    window_handle,
1446
0
                    process_handle,
1447
0
                    process_id,
1448
0
                    state_sender,
1449
0
                    highlight_sender,
1450
0
                    // Placeholder - `Clients::push` overwrites with the
1451
0
                    // dense `list.len()`-based tile index.
1452
0
                    tile_index: 0,
1453
0
                },
1454
0
            );
1455
0
        });
1456
1457
0
        tasks.push(task);
1458
    }
1459
1460
    // Wait for all tasks to complete in parallel
1461
0
    let mut results = Vec::new();
1462
0
    for task in tasks {
1463
0
        match task.await {
1464
0
            Ok(result) => results.push(result),
1465
0
            Err(e) => panic!("Failed to launch client: {e}"),
1466
        }
1467
    }
1468
1469
    // Sort results by index to maintain order
1470
0
    results.sort_by_key(|(index, _)| return *index);
1471
1472
0
    let mut clients = Clients::new();
1473
0
    for (_, client) in results.into_iter() {
1474
0
        clients.push(client);
1475
0
    }
1476
0
    return clients;
1477
0
}
1478
1479
/// Launchs a `client` console process with its own window with the given
1480
/// CLI arguments/options: `host`, `username`, `port`, `debug`.
1481
///
1482
/// Waits for the window to open, then re-arranges it based on
1483
/// the total number of clients, the size of the daemon console window and
1484
/// its index relative to the other client windows.
1485
///
1486
/// # Arguments
1487
///
1488
/// * `windows_api`             - The Windows API implementation to use
1489
/// * `host`                    - Hostname the client should connect to
1490
/// * `username`                - Username the client should use
1491
/// * `port`                    - Optional port for SSH connections
1492
/// * `debug`                   - Toggle debug mode on the client
1493
/// * `index`                   - The index of the client in the list of all clients.
1494
///                               Used to re-arrange the client window.
1495
/// * `workspace_area`          - The available workspace area on the primary monitor
1496
///                               minus the space occupied by the daemon console window.
1497
/// * `number_of_consoles`      - The total number of active client console windows.
1498
/// * `aspect_ratio_adjustment` - The `aspect_ratio_adjustment` daemon configuration.
1499
///
1500
/// # Returns
1501
///
1502
/// A tuple containing the window handle, process handle, and process id of the
1503
/// client process.
1504
0
fn launch_client_console<W: WindowsApi>(
1505
0
    windows_api: &W,
1506
0
    host: &str,
1507
0
    username: Option<String>,
1508
0
    port: Option<u16>,
1509
0
    debug: bool,
1510
0
    index: usize,
1511
0
    workspace_area: &workspace::WorkspaceArea,
1512
0
    number_of_consoles: usize,
1513
0
    aspect_ratio_adjustment: f64,
1514
0
) -> (HWND, HANDLE, u32) {
1515
    // The first argument must be `--` to ensure all following arguments are treated
1516
    // as positional arguments and not as options if they start with `-`.
1517
0
    let mut client_args: Vec<String> = Vec::new();
1518
0
    if debug {
1519
0
        client_args.push("-d".to_string());
1520
0
    }
1521
0
    let mut actual_host = host;
1522
0
    let mut actual_username = username;
1523
0
    if let Some(split_result) = host.split_once("@") {
1524
0
        actual_username = Some(split_result.0.to_owned());
1525
0
        actual_host = split_result.1;
1526
0
    }
1527
0
    if let Some(actual_username) = actual_username.as_deref() {
1528
0
        client_args.extend(vec!["-u".to_string(), actual_username.to_string()]);
1529
0
    }
1530
0
    if let Some(port) = port {
1531
0
        client_args.extend(vec!["-p".to_string(), port.to_string()]);
1532
0
    }
1533
0
    client_args.push("client".to_string());
1534
0
    client_args.extend(vec!["--".to_string(), actual_host.to_string()]);
1535
1536
0
    let process_info = spawn_console_process(windows_api, &current_exe_path(), client_args, false)
1537
0
        .expect("Failed to create process");
1538
0
    let client_window_handle = get_console_window_handle(windows_api, process_info.dwProcessId);
1539
0
    let process_handle = windows_api
1540
0
        .open_process(PROCESS_QUERY_INFORMATION.0, false, process_info.dwProcessId)
1541
0
        .unwrap_or_else(|err| {
1542
0
            panic!(
1543
                "Failed to open process handle for process {}: {}",
1544
                process_info.dwProcessId, err
1545
            );
1546
        });
1547
1548
0
    arrange_client_window(
1549
0
        windows_api,
1550
0
        &client_window_handle,
1551
0
        workspace_area,
1552
0
        index,
1553
0
        number_of_consoles,
1554
0
        aspect_ratio_adjustment,
1555
    );
1556
0
    return (
1557
0
        client_window_handle,
1558
0
        process_handle,
1559
0
        process_info.dwProcessId,
1560
0
    );
1561
0
}
1562
1563
/// Correlate the connecting client by PID, then multiplex input records,
1564
/// [`ClientState`] updates, and keep-alives onto the named pipe.
1565
///
1566
/// The post-subscribe initial-state push is intentional: `state_receiver.changed`
1567
/// only fires on transitions observed *after* `subscribe`, so a state set
1568
/// in the brief window between [`Client`] construction and `subscribe`
1569
/// would otherwise leave the client on its default until the next
1570
/// transition.
1571
///
1572
/// The `select!` is biased toward `recv` so the keep-alive tick never
1573
/// preempts active input traffic; the [`ClientState::Disabled`] arm
1574
/// therefore probes the pipe itself, otherwise sustained input would
1575
/// hide a disconnect.
1576
///
1577
/// # Errors and termination
1578
///
1579
/// An unknown PID exits the process (production) or panics (tests) -
1580
/// the daemon's bookkeeping is broken and recovery is not possible.
1581
/// A failed pipe write or a dropped [`watch::Sender`] ends the routine
1582
/// cleanly.
1583
8
async fn named_pipe_server_routine(
1584
8
    server: NamedPipeServer,
1585
8
    receiver: &mut Receiver<[u8; SERIALIZED_INPUT_RECORD_0_LENGTH]>,
1586
8
    clients: Arc<Mutex<Clients>>,
1587
8
) {
1588
    // wait for a client to connect
1589
8
    server.connect().await.unwrap_or_else(|err| 
{0
1590
0
        error!("{}", err);
1591
0
        panic!("Timed out waiting for clients to connect to named pipe server",)
1592
    });
1593
1594
    // Correlate the connecting client by reading its 4 byte PID.
1595
8
    let 
pid7
= read_client_pid(&server).await;
1596
7
    let (
mut state_receiver6
,
mut highlight_receiver6
) = match clients.lock().unwrap().get_by_pid(pid)
1597
    {
1598
6
        Some(client) => (
1599
6
            client.state_sender.subscribe(),
1600
6
            client.highlight_sender.subscribe(),
1601
6
        ),
1602
        None => {
1603
1
            error!(
1604
                "Named pipe server received unknown PID {} - daemon bookkeeping broken",
1605
                pid
1606
            );
1607
            // In production this exits the daemon; in tests process::exit would kill
1608
            // the test runner, so we panic instead so tokio::spawn can catch it.
1609
            #[cfg(not(test))]
1610
            std::process::exit(1);
1611
            #[cfg(test)]
1612
1
            panic!("Unknown client PID {} - daemon bookkeeping broken", pid);
1613
        }
1614
    };
1615
1616
    // Initial state push - see fn docs.
1617
6
    let initial_state = *state_receiver.borrow_and_update();
1618
6
    let initial_state_frame: [u8; FRAMED_STATE_CHANGE_LENGTH] =
1619
6
        [TAG_STATE_CHANGE, serialize_client_state(initial_state)];
1620
6
    if !write_framed_message(&server, &initial_state_frame).await {
1621
0
        return;
1622
6
    }
1623
1624
    // Initial highlight push - same rationale as the state push above.
1625
6
    let initial_highlight = *highlight_receiver.borrow_and_update();
1626
6
    let initial_highlight_frame: [u8; FRAMED_HIGHLIGHT_LENGTH] =
1627
6
        [TAG_HIGHLIGHT, serialize_highlight(initial_highlight)];
1628
6
    if !write_framed_message(&server, &initial_highlight_frame).await {
1629
0
        return;
1630
6
    }
1631
1632
    loop {
1633
        // Independent watch channels: `state_receiver` and `highlight_receiver` are forwarded over the pipe in whichever order this `select!` happens to pick them up, not the order the daemon-side senders fired.
1634
24
        tokio::select! {
1635
            biased;
1636
24
            
recv_result16
= receiver.recv() => {
1637
14
                let ser_input_record = match 
recv_result2
{
1638
14
                    Ok(val) => val,
1639
1
                    Err(RecvError::Lagged(skipped)) => {
1640
                        // Slow consumers (typically disabled clients) drop
1641
                        // records rather than kill the routine; debug-level
1642
                        // because this can fire repeatedly under load.
1643
1
                        debug!(
1644
                            "Named pipe server routine lagged behind broadcast channel - dropping {} record(s)",
1645
                            skipped
1646
                        );
1647
                        // Probe and yield so sustained lag cannot starve
1648
                        // the keep-alive tick (the `select!` is `biased`
1649
                        // toward `recv`) and so a closed pipe is still
1650
                        // detected promptly under load.
1651
1
                        if !probe_pipe_alive(&server) {
1652
0
                            return;
1653
1
                        }
1654
1
                        tokio::task::yield_now().await;
1655
1
                        continue;
1656
                    }
1657
                    Err(RecvError::Closed) => {
1658
1
                        error!("Broadcast channel closed");
1659
1
                        panic!("Failed to receive data from the Receiver");
1660
                    }
1661
                };
1662
                // Copy out before any `.await` - `watch::Ref` is not `Send`.
1663
14
                let current_state = *state_receiver.borrow();
1664
14
                match current_state {
1665
7
                    ClientState::Active => {}
1666
                    ClientState::Disabled => {
1667
                        // Probe the pipe so a disabled client cannot hide a
1668
                        // disconnect under sustained input - the keep-alive
1669
                        // tick is starved while recv keeps yielding records.
1670
7
                        if !probe_pipe_alive(&server) {
1671
0
                            return;
1672
7
                        }
1673
7
                        tokio::task::yield_now().await;
1674
7
                        continue;
1675
                    }
1676
                }
1677
7
                let mut frame = [0u8; FRAMED_INPUT_RECORD_LENGTH];
1678
7
                frame[0] = TAG_INPUT_RECORD;
1679
7
                frame[1..].copy_from_slice(&ser_input_record);
1680
7
                if !write_framed_message(&server, &frame).await {
1681
0
                    return;
1682
7
                }
1683
            }
1684
24
            
changed_result2
= state_receiver.changed() => {
1685
                // Sender dropped - the daemon has removed this client from its
1686
                // bookkeeping, so there is nothing left to forward.
1687
2
                if changed_result.is_err() {
1688
0
                    debug!(
1689
                        "Client state sender dropped, stopping named pipe server routine ({:?})",
1690
                        server
1691
                    );
1692
0
                    return;
1693
2
                }
1694
2
                let state = *state_receiver.borrow_and_update();
1695
2
                let frame: [u8; FRAMED_STATE_CHANGE_LENGTH] =
1696
2
                    [TAG_STATE_CHANGE, serialize_client_state(state)];
1697
2
                if !write_framed_message(&server, &frame).await {
1698
0
                    return;
1699
2
                }
1700
            }
1701
24
            
changed_result0
= highlight_receiver.changed() => {
1702
                // Sender dropped - same rationale as the `state_receiver` arm.
1703
0
                if changed_result.is_err() {
1704
0
                    debug!(
1705
                        "Client highlight sender dropped, stopping named pipe server routine ({:?})",
1706
                        server
1707
                    );
1708
0
                    return;
1709
0
                }
1710
0
                let highlighted = *highlight_receiver.borrow_and_update();
1711
0
                let frame: [u8; FRAMED_HIGHLIGHT_LENGTH] =
1712
0
                    [TAG_HIGHLIGHT, serialize_highlight(highlighted)];
1713
0
                if !write_framed_message(&server, &frame).await {
1714
0
                    return;
1715
0
                }
1716
            }
1717
24
            _ = tokio::time::sleep(Duration::from_millis(5)) => {
1718
6
                if !write_framed_message(&server, &[TAG_KEEP_ALIVE]).await {
1719
5
                    return;
1720
1
                }
1721
            }
1722
        }
1723
    }
1724
5
}
1725
1726
/// Best-effort, non-blocking probe of the named pipe.
1727
///
1728
/// Returns `true` if a single `TAG_KEEP_ALIVE` byte either wrote
1729
/// successfully or returned `WouldBlock` (the pipe is still open but
1730
/// the OS buffer is full); `false` if any other error indicates the
1731
/// pipe is closed.
1732
8
fn probe_pipe_alive(server: &NamedPipeServer) -> bool {
1733
8
    match server.try_write(&[TAG_KEEP_ALIVE]) {
1734
7
        Ok(_) => return true,
1735
1
        Err(e) if e.kind() == io::ErrorKind::WouldBlock => return true,
1736
        Err(_) => {
1737
0
            debug!(
1738
                "Named pipe server ({:?}) is closed, stopping named pipe server routine",
1739
                server
1740
            );
1741
0
            return false;
1742
        }
1743
    }
1744
8
}
1745
1746
/// Write all of `frame` to the named pipe server, retrying partial
1747
/// writes and `WouldBlock` results until the buffer is fully drained.
1748
///
1749
/// Returns `true` on full write, `false` if the pipe is closed.
1750
///
1751
/// # Panics
1752
///
1753
/// Panics if waiting for the pipe to become writable returns an error.
1754
27
async fn write_framed_message(server: &NamedPipeServer, frame: &[u8]) -> bool {
1755
27
    let mut written = 0usize;
1756
63
    while written < frame.len() {
1757
41
        server.writable().await.unwrap_or_else(|err| 
{0
1758
0
            error!("{}", err);
1759
0
            panic!("Timed out waiting for named pipe server to become writable",)
1760
        });
1761
41
        match server.try_write(&frame[written..]) {
1762
22
            Ok(n) => {
1763
22
                written += n;
1764
22
                if written < frame.len() {
1765
0
                    warn!(
1766
                        "Partially written data, expected {} but only wrote {} so far",
1767
0
                        frame.len(),
1768
                        written
1769
                    );
1770
22
                }
1771
            }
1772
19
            Err(
e14
) if e.kind() == io::ErrorKind::WouldBloc
k14
=> {
1773
                // Try again
1774
14
                debug!("Writing to named pipe server would have blocked");
1775
14
                continue;
1776
            }
1777
            Err(_) => {
1778
                // Can happen if the pipe is closed because the
1779
                // client exited
1780
5
                debug!(
1781
                    "Named pipe server ({:?}) is closed, stopping named pipe server routine",
1782
                    server
1783
                );
1784
5
                return false;
1785
            }
1786
        }
1787
    }
1788
22
    debug!("Successfully written all data");
1789
22
    return true;
1790
27
}
1791
1792
/// Read the connecting client's 4 byte little-endian process id from the pipe.
1793
///
1794
/// Reads exactly 4 bytes from `server`, retrying on `WouldBlock`, and decodes
1795
/// them as a `u32`. Any non-recoverable I/O error panics, as a client that
1796
/// cannot send its PID cannot be correlated and forwarding would be
1797
/// impossible.
1798
///
1799
/// # Arguments
1800
///
1801
/// * `server` - The connected named pipe server to read from.
1802
///
1803
/// # Returns
1804
///
1805
/// The process id sent by the client.
1806
///
1807
/// # Panics
1808
///
1809
/// Panics if the pipe is closed before 4 bytes can be read, or if any
1810
/// non-`WouldBlock` I/O error occurs.
1811
8
async fn read_client_pid(server: &NamedPipeServer) -> u32 {
1812
8
    let mut buf = [0u8; SERIALIZED_PID_LENGTH];
1813
8
    let mut read = 0usize;
1814
15
    while read < SERIALIZED_PID_LENGTH {
1815
8
        server.readable().await.unwrap_or_else(|err| 
{0
1816
0
            panic!("Named pipe server is not readable for PID handshake: {err}")
1817
        });
1818
8
        match server.try_read(&mut buf[read..]) {
1819
            Ok(0) => {
1820
1
                panic!("Named pipe server closed before PID handshake completed");
1821
            }
1822
7
            Ok(n) => {
1823
7
                read += n;
1824
7
            }
1825
0
            Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
1826
0
                continue;
1827
            }
1828
0
            Err(e) => {
1829
0
                panic!("Failed to read PID from named pipe client: {e}");
1830
            }
1831
        }
1832
    }
1833
7
    return deserialize_pid(&buf);
1834
7
}
1835
1836
/// Re-sizes and re-positions the given client window based on the total number of clients,
1837
/// the size of the daemon console window and its index relative to the other client windows.
1838
///
1839
/// # Arguments
1840
///
1841
/// * `windows_api`              - The Windows API implementation to use
1842
/// * `handle`                   - Reference the windows handle of a client console window.
1843
/// * `workspace_area`           - The available workspace area on the primary monitor
1844
///                                minus the space occupied by the daemon console window.
1845
/// * `index`                    - The index of the client in the list of all clients.
1846
/// * `number_of_consoles`       - The total number of active client console windows.
1847
/// * `aspect_ratio_adjustment` - The `aspect_ratio_adjustment` daemon configuration.
1848
0
fn arrange_client_window<W: WindowsApi>(
1849
0
    windows_api: &W,
1850
0
    handle: &HWND,
1851
0
    workspace_area: &workspace::WorkspaceArea,
1852
0
    index: usize,
1853
0
    number_of_consoles: usize,
1854
0
    aspect_ratio_adjustment: f64,
1855
0
) {
1856
0
    let (x, y, width, height) = determine_client_spatial_attributes(
1857
0
        index as i32,
1858
0
        number_of_consoles as i32,
1859
0
        workspace_area,
1860
0
        aspect_ratio_adjustment,
1861
0
    );
1862
    // Since windows update 10.0.19041.5072 it can happen that a client windows rendering is broken
1863
    // after a move+resize. Why is unclear, but resizing again does solve the issue.
1864
    // We first make the window 1 pixel in each dimension too small and imediately fix it.
1865
    // To reduce overhead we do not repaint the window the first time.
1866
0
    windows_api
1867
0
        .move_window(*handle, x, y, width - 1, height - 1, false)
1868
0
        .unwrap_or_else(|err| {
1869
0
            error!("{}", err);
1870
0
            panic!("Failed to move window",)
1871
        });
1872
0
    windows_api
1873
0
        .move_window(*handle, x, y, width, height, true)
1874
0
        .unwrap_or_else(|err| {
1875
0
            error!("{}", err);
1876
0
            panic!("Failed to move window",)
1877
        });
1878
0
}
1879
1880
/// Return the workspace area's aspect ratio (width / height) including
1881
/// the frame padding the tiler accounts for.
1882
///
1883
/// # Arguments
1884
///
1885
/// * `workspace_area` - Available workspace minus the daemon console.
1886
///
1887
/// # Returns
1888
///
1889
/// Aspect ratio as a `f64` for use by both the tiler and the navigation
1890
/// grid.
1891
3
fn workspace_aspect_ratio(workspace_area: &workspace::WorkspaceArea) -> f64 {
1892
3
    return (workspace_area.width + (workspace_area.x_fixed_frame + workspace_area.x_size_frame) * 2)
1893
3
        as f64
1894
3
        / (workspace_area.height + (workspace_area.y_fixed_frame + workspace_area.y_size_frame) * 2)
1895
3
            as f64;
1896
3
}
1897
1898
/// Calculates the position and dimensions for a client window given its index,
1899
/// the total number of clients and the `aspect_ratio_adjustment` daemon configuration.
1900
///
1901
/// # Arguments
1902
///
1903
/// * `index`                    - The index of the client in the list of all clients.
1904
/// * `number_of_consoles`       - The total number of active client console windows.
1905
/// * `workspace_area`           - The available workspace area on the primary monitor
1906
///                                minus the space occupied by the daemon console window.
1907
/// * `aspect_ratio_adjustment` - The `aspect_ratio_adjustment` daemon configuration.
1908
///     * `> 0.0` - Aims for vertical rectangle shape.
1909
///       The larger the value, the more exaggerated the "verticality".
1910
///       Eventually the windows will all be columns.
1911
///     * `= 0.0` - Aims for square shape.
1912
///     * `< 0.0` - Aims for horizontal rectangle shape.
1913
///       The smaller the value, the more exaggerated the "horizontality".
1914
///       Eventually the windows will all be rows.
1915
///       `-1.0` is the sweetspot for mostly preserving a 16:9 ratio.
1916
0
fn determine_client_spatial_attributes(
1917
0
    index: i32,
1918
0
    number_of_consoles: i32,
1919
0
    workspace_area: &workspace::WorkspaceArea,
1920
0
    aspect_ratio_adjustment: f64,
1921
0
) -> (i32, i32, i32, i32) {
1922
0
    let aspect_ratio = workspace_aspect_ratio(workspace_area);
1923
0
    let (grid_columns, grid_rows) =
1924
0
        grid_dimensions(number_of_consoles, aspect_ratio, aspect_ratio_adjustment);
1925
1926
0
    let grid_column_index = index % grid_columns;
1927
0
    let grid_row_index = index / grid_columns;
1928
1929
0
    let is_last_row = grid_row_index == grid_rows - 1;
1930
0
    let last_row_console_count = number_of_consoles % grid_columns;
1931
1932
0
    let console_width = if is_last_row && last_row_console_count != 0 {
1933
0
        (workspace_area.width / last_row_console_count)
1934
0
            + if last_row_console_count > 1 {
1935
0
                workspace_area.x_fixed_frame + workspace_area.x_size_frame
1936
            } else {
1937
0
                0
1938
            }
1939
    } else {
1940
0
        (workspace_area.width / grid_columns)
1941
0
            + (workspace_area.x_fixed_frame + workspace_area.x_size_frame)
1942
    };
1943
1944
0
    let console_height = (workspace_area.height
1945
0
        + (workspace_area.y_fixed_frame + workspace_area.y_size_frame) * grid_row_index)
1946
0
        / grid_rows;
1947
1948
0
    let x = grid_column_index * console_width
1949
0
        - ((workspace_area.x_fixed_frame + workspace_area.x_size_frame) * (grid_column_index + 1));
1950
0
    let y = grid_row_index * console_height
1951
0
        - ((workspace_area.y_fixed_frame + workspace_area.y_size_frame) * (grid_row_index - 1));
1952
1953
0
    return get_console_rect(x, y, console_width, console_height, workspace_area);
1954
0
}
1955
1956
/// Transform the position and dimensions of a console window based
1957
/// on the workspace area.
1958
///
1959
/// To minimize empty space between windows, width and height must be adjusted
1960
/// by the `fixed_frame` and `size_frame` values.
1961
///
1962
/// # Arguments
1963
///
1964
/// * `x`              - The `x` coordinate of the window.
1965
/// * `y`              - The `y` coordinate of the window.
1966
/// * `width`          - The `width` in pixels of the window.
1967
/// * `height`         - The `height` in pixels of the window.
1968
/// * `workspace_area` - The available workspace area on the primary monitor minus
1969
///                      the space occupied by the daemon console window.
1970
///
1971
/// # Returns
1972
///
1973
/// (`x`, `y`, `width`, `height`)
1974
///
1975
0
fn get_console_rect(
1976
0
    x: i32,
1977
0
    y: i32,
1978
0
    width: i32,
1979
0
    height: i32,
1980
0
    workspace_area: &workspace::WorkspaceArea,
1981
0
) -> (i32, i32, i32, i32) {
1982
0
    return (
1983
0
        std::cmp::max(
1984
0
            workspace_area.x - (workspace_area.x_fixed_frame + workspace_area.x_size_frame),
1985
0
            workspace_area.x - (workspace_area.x_fixed_frame + workspace_area.x_size_frame) + x,
1986
0
        ),
1987
0
        workspace_area.y - (workspace_area.y_fixed_frame + workspace_area.y_size_frame) + y,
1988
0
        std::cmp::min(workspace_area.width, width),
1989
0
        height,
1990
0
    );
1991
0
}
1992
1993
/// Spawns a background thread that ensures the z-order of all client
1994
/// windows is in sync with the daemon window.
1995
/// I.e. if the daemon window is focussed, all clients should be moved to the foreground.
1996
///
1997
/// # Arguments
1998
///
1999
/// * `windows_api` - Arc-wrapped Windows API implementation for thread-safe access
2000
/// * `clients`     - A thread safe mapping from the number
2001
///                   a client console window was launched at
2002
///                   in relation to the other client windows
2003
///                   and the clients console window handle.
2004
///                   The mapping must be thread safe to allow
2005
///                   it to be modified by the main thread
2006
///                   while we periodically read from it in the
2007
///                   background thread.
2008
0
fn ensure_client_z_order_in_sync_with_daemon<W: WindowsApi + Send + Sync + 'static>(
2009
0
    windows_api: Arc<W>,
2010
0
    clients: Arc<Mutex<Clients>>,
2011
0
) {
2012
0
    tokio::spawn(async move {
2013
0
        let daemon_handle = get_console_window_wrapper(windows_api.as_ref());
2014
0
        let mut previous_foreground_window = get_foreground_window_wrapper(windows_api.as_ref());
2015
        loop {
2016
0
            tokio::time::sleep(Duration::from_millis(1)).await;
2017
0
            let foreground_window = get_foreground_window_wrapper(windows_api.as_ref());
2018
0
            if previous_foreground_window == foreground_window {
2019
0
                continue;
2020
0
            }
2021
0
            if foreground_window == daemon_handle
2022
0
                && !clients.lock().unwrap().iter().any(|client| {
2023
0
                    return client.window_handle == previous_foreground_window.hwdn
2024
0
                        || client.window_handle == daemon_handle.hwdn;
2025
0
                })
2026
0
            {
2027
0
                defer_windows(
2028
0
                    windows_api.as_ref(),
2029
0
                    &clients.lock().unwrap(),
2030
0
                    &daemon_handle.hwdn,
2031
0
                );
2032
0
            }
2033
0
            previous_foreground_window = foreground_window;
2034
        }
2035
    });
2036
0
}
2037
2038
/// Move all given windows to the foreground.
2039
///
2040
/// Restores minimized windows.
2041
/// If a window handle no longer points to a valid window, it is skipped.
2042
/// The daemon window is deferred last and receives focus.
2043
///
2044
/// # Arguments
2045
///
2046
/// * `windows_api`                   - The Windows API implementation to use
2047
/// * `clients`                       - A thread safe mapping from the number
2048
///                                     a client console window was launched at
2049
///                                     in relation to the other client windows
2050
///                                     and the clients console window handle.
2051
/// * `daemon_handle`                 - Handle to the daemon console window.
2052
0
fn defer_windows<W: WindowsApi>(windows_api: &W, clients: &[Client], daemon_handle: &HWND) {
2053
0
    for client in clients.iter() {
2054
0
        restore_if_minimized(windows_api, client.window_handle, false);
2055
0
        let _ = windows_api.bring_window_to_top(client.window_handle, false);
2056
0
    }
2057
    // Raise the daemon last so it ends up on top and keeps keyboard focus.
2058
0
    restore_if_minimized(windows_api, *daemon_handle, true);
2059
0
    let _ = windows_api.bring_window_to_top(*daemon_handle, true);
2060
0
}
2061
2062
/// Restore `window_handle` if its current placement reports minimized.
2063
///
2064
/// Silently does nothing when the placement query fails or the window is
2065
/// not minimized. Used by [`defer_windows`] so both client and daemon
2066
/// windows are brought back from the taskbar before z-order updates.
2067
///
2068
/// # Arguments
2069
///
2070
/// * `windows_api`         - Windows API implementation.
2071
/// * `window_handle`       - Handle to the window to potentially restore.
2072
/// * `with_keyboard_focus` - Whether the restored window should be activated.
2073
///                           Pass `false` for client windows so unminimizing
2074
///                           them does not steal foreground from the daemon -
2075
///                           `SW_RESTORE` activates, which would let the
2076
///                           last-restored client win the foreground race and
2077
///                           block [`WindowsApi::bring_window_to_top`] from
2078
///                           refocusing the daemon.
2079
0
fn restore_if_minimized<W: WindowsApi>(
2080
0
    windows_api: &W,
2081
0
    window_handle: HWND,
2082
0
    with_keyboard_focus: bool,
2083
0
) {
2084
0
    let placement = match windows_api.get_window_placement(window_handle) {
2085
0
        Ok(placement) => placement,
2086
0
        Err(_) => return,
2087
    };
2088
0
    if placement.showCmd == SW_SHOWMINIMIZED.0.try_into().unwrap() {
2089
0
        let cmd = if with_keyboard_focus {
2090
0
            SW_RESTORE
2091
        } else {
2092
0
            SW_SHOWNOACTIVATE
2093
        };
2094
0
        let _ = windows_api.show_window(window_handle, cmd);
2095
0
    }
2096
0
}
2097
2098
/// The entrypoint for the `daemon` subcommand.
2099
///
2100
/// Spawns 1 client process with its own window for each host
2101
/// and 1 worker thread that handles communication with the client
2102
/// over a named pipe.
2103
/// Responsible for client window positioning and sizing.
2104
/// Handles control mode.
2105
/// Main thread reads input records from the console input buffer
2106
/// and propagates them via the background threads to all clients
2107
/// simultaneously.
2108
///
2109
/// # Arguments
2110
///
2111
/// * `windows_api` - The Windows API implementation to use
2112
/// * `hosts`    - List of hostnames for which to launch clients.
2113
/// * `username` - Username used to connect to the hosts.
2114
///                If none, each client will use the SSH config to determine
2115
///                a suitable username for their respective host.
2116
/// * `port`     - Optional port used for all SSH connections.
2117
/// * `config`   - The `DaemonConfig`.
2118
/// * `debug`    - Enables debug logging
2119
0
pub async fn main<W: WindowsApi + Clone + 'static>(
2120
0
    windows_api: &W,
2121
0
    hosts: Vec<String>,
2122
0
    username: Option<String>,
2123
0
    port: Option<u16>,
2124
0
    config: &DaemonConfig,
2125
0
    clusters: &[Cluster],
2126
0
    debug: bool,
2127
0
) {
2128
0
    let daemon: Daemon = Daemon {
2129
0
        hosts: explode(&hosts.join(" ")).unwrap_or(hosts),
2130
0
        username,
2131
0
        port,
2132
0
        config,
2133
0
        clusters,
2134
0
        control_mode_state: ControlModeState::Inactive,
2135
0
        debug,
2136
0
    };
2137
0
    daemon.launch(windows_api).await;
2138
0
    debug!("Actually exiting");
2139
0
}
2140
2141
#[cfg(test)]
2142
#[path = "../tests/daemon/test_mod.rs"]
2143
mod test_mod;